Implementing Pagination and Large Dataset Handling in iOS
Course: iOS App Development
Topic: Implementing Pagination and Large Dataset Handling
Course Link: iOS App Development Training
Demo Registration: Register for a Free Course Demo
JustAcademy Website: Visit JustAcademy
1. Introduction
Modern iOS applications often display large amounts of information, including product catalogs, social media feeds, transaction histories, search results, news articles, and image galleries. Loading every record at once can consume significant memory, increase network usage, slow down the interface, and create a poor user experience.
Pagination is a technique that divides a large dataset into smaller portions and loads them gradually. Instead of downloading thousands of records immediately, an application retrieves an initial batch and requests more data when the user approaches the end of the currently displayed content.
In iOS development, pagination can be implemented with Swift, UIKit components such as UITableView and UICollectionView, SwiftUI views such as List and ScrollView, and asynchronous networking APIs.
2. Learning Objectives
- Understand pagination and why large datasets require efficient handling.
- Differentiate between page-based and cursor-based pagination.
- Implement infinite scrolling in UITableView and UICollectionView.
- Fetch paginated data asynchronously using URLSession and Swift concurrency.
- Manage loading indicators, errors, empty results, and retry operations.
- Prevent duplicate requests and duplicate records.
- Improve memory usage, scrolling performance, and image loading.
- Understand caching, prefetching, filtering, and refresh behavior.
3. What Is Pagination?
Pagination is the process of retrieving and presenting a limited number of records at a time. The application loads one portion of data, then retrieves another portion when necessary.
For example, an online shopping application may contain 5,000 products. Instead of downloading all 5,000 products in one request, it might load 20 products initially and request another 20 when the user scrolls toward the bottom.
| Request |
Records Returned |
Total Loaded |
| Initial request |
1–20 |
20 |
| Second request |
21–40 |
40 |
| Third request |
41–60 |
60 |
| Fourth request |
61–80 |
80 |
The application requests more records only when required, reducing unnecessary data transfer and improving the initial loading experience.
4. Why Is Large Dataset Handling Important?
- Memory efficiency: Avoids keeping unnecessary records and image data in memory.
- Faster initial loading: Displays the first set of useful content without waiting for the entire dataset.
- Reduced network usage: Requests only the data needed by the current browsing session.
- Smoother scrolling: Prevents expensive data processing from blocking the main thread.
- Better scalability: Supports datasets that may grow from hundreds to millions of server-side records.
- Improved user experience: Allows users to browse continuously with visible loading and error states.
5. Common Pagination Strategies
5.1 Page-Based Pagination
Page-based pagination identifies a batch using a page number and page size. For example, page 1 returns the first 20 records, page 2 returns the next 20, and so on.
GET /api/products?page=1&limit=20
GET /api/products?page=2&limit=20
GET /api/products?page=3&limit=20
Advantages: Simple to implement, easy to understand, and suitable for many catalog and administrative applications.
Limitations: If records are inserted or deleted between requests, page boundaries can shift and records may be repeated or skipped. Large page offsets can also become expensive for some database queries.
5.2 Offset-Based Pagination
Offset pagination specifies how many records to skip and how many records to return.
GET /api/products?offset=0&limit=20
GET /api/products?offset=20&limit=20
GET /api/products?offset=40&limit=20
Here, offset indicates the number of records to skip, while limit indicates the maximum number of records returned.
5.3 Cursor-Based Pagination
Cursor-based pagination uses a continuation token or cursor returned by the server. The next request uses that cursor to retrieve the following batch.
GET /api/products?limit=20
GET /api/products?limit=20&cursor=eyJpZCI6MjB9
The cursor shown above is illustrative. Real cursors are generated and interpreted by the API server.
Advantages: Often works well for continuously updated feeds and large datasets. It can be more stable than offsets when records are added while the user browses, provided the server uses an appropriate ordering strategy.
Limitations: Requires API support, and clients must treat cursors as opaque values rather than inventing or modifying them.
5.4 Infinite Scrolling
Infinite scrolling automatically requests more records as the user approaches the end of the currently loaded list. Social feeds, product grids, and search results commonly use this pattern.
5.5 Traditional Page Navigation
Traditional pagination presents controls such as Previous, Next, and page numbers. It can be useful for reports, structured records, and interfaces where users need to move between known pages.
6. Designing a Paginated API Response
A well-designed API should communicate the returned records and how the client can continue loading data. The exact response format depends on the backend.
{
"items": [
{
"id": 101,
"name": "Product A"
},
{
"id": 102,
"name": "Product B"
}
],
"nextCursor": "opaque-server-generated-token",
"hasMore": true
}
In this example, items contains the current batch, nextCursor identifies the continuation point, and hasMore indicates whether additional results are available.
A page-based API might instead return page, limit, and totalPages. Always follow the actual backend contract rather than assuming every API uses the same fields.
7. Creating a Swift Model
Consider a product feed in which each product has an identifier, name, and price.
import Foundation
struct Product: Codable, Identifiable {
let id: Int
let name: String
let price: Double
}
struct ProductPage: Decodable {
let items: [Product]
let nextCursor: String?
let hasMore: Bool
}
The Product structure represents an individual record. The ProductPage structure represents one response from the paginated API.
When the server uses a different response structure, update the model to match it. For example, some APIs place the records inside a data property or return pagination metadata in a separate object.
8. Fetching Data with URLSession
URLSession provides networking capabilities for making HTTP requests. The following example uses Swift concurrency to retrieve a page of products from an API that supports cursor-based pagination.
import Foundation
enum APIError: Error {
case invalidResponse
case unsuccessfulStatus(Int)
}
struct ProductAPI {
let baseURL = URL(string: "https://example.com/api/products")!
func fetchProducts(cursor: String?) async throws -> ProductPage {
var components = URLComponents(
url: baseURL,
resolvingAgainstBaseURL: false
)!
var queryItems = [
URLQueryItem(name: "limit", value: "20")
]
if let cursor {
queryItems.append(
URLQueryItem(name: "cursor", value: cursor)
)
}
components.queryItems = queryItems
guard let url = components.url else {
throw APIError.invalidResponse
}
let (data, response) = try await URLSession.shared.data(
from: url
)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw APIError.unsuccessfulStatus(
httpResponse.statusCode
)
}
return try JSONDecoder().decode(
ProductPage.self,
from: data
)
}
}
Important: Replace the example endpoint with a real API that returns the expected JSON structure. A URL that does not exist or a response with a different schema will cause the request or decoding process to fail.
9. Managing Pagination State
A paginated screen needs to track its currently loaded records and request state. Common state variables include:
| Variable |
Purpose |
products |
Stores records already loaded. |
nextCursor |
Stores the cursor for the next request. |
hasMore |
Indicates whether more records are available. |
isLoading |
Prevents overlapping requests and controls loading indicators. |
errorMessage |
Stores a user-friendly error message. |
These values should be updated consistently. If the next-page request fails, do not advance the cursor or mark the entire dataset as complete unless the API explicitly indicates that no more data exists.
10. Implementing a Pagination View Model
A view model separates loading logic from the user interface. This example appends each successfully retrieved page to the existing list and prevents simultaneous page requests.
import Foundation
@MainActor
final class ProductListViewModel: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var isLoading = false
@Published private(set) var hasMore = true
@Published var errorMessage: String?
private let api = ProductAPI()
private var nextCursor: String?
private var loadedIDs = Set()
func loadNextPage() async {
guard !isLoading, hasMore else {
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let page = try await api.fetchProducts(
cursor: nextCursor
)
for product in page.items {
if loadedIDs.insert(product.id).inserted {
products.append(product)
}
}
nextCursor = page.nextCursor
hasMore = page.hasMore
} catch {
errorMessage = "Unable to load products. Please try again."
}
}
func refresh() async {
products.removeAll()
loadedIDs.removeAll()
nextCursor = nil
hasMore = true
errorMessage = nil
await loadNextPage()
}
}
This example assumes the API returns a valid continuation cursor and a reliable hasMore value. If the backend uses a different pagination scheme, adapt the state and request parameters accordingly.
The set of loaded identifiers prevents duplicate records from being appended. For production apps, consider how refresh requests interact with in-flight requests so that an older response cannot overwrite newer results.
11. Implementing Infinite Scrolling in SwiftUI
In SwiftUI, a list can trigger the next request when a row near the end appears. This is a simple implementation using the view model above.
import SwiftUI
struct ProductListView: View {
@StateObject private var viewModel = ProductListViewModel()
var body: some View {
NavigationStack {
List {
ForEach(viewModel.products) { product in
VStack(alignment: .leading, spacing: 6) {
Text(product.name)
.font(.headline)
Text(product.price, format: .currency(code: "USD"))
.font(.subheadline)
}
.onAppear {
if product.id == viewModel.products.last?.id {
Task {
await viewModel.loadNextPage()
}
}
}
}
if viewModel.isLoading {
HStack {
Spacer()
ProgressView("Loading more...")
Spacer()
}
}
if let message = viewModel.errorMessage {
VStack(spacing: 8) {
Text(message)
.foregroundStyle(.secondary)
Button("Retry") {
Task {
await viewModel.loadNextPage()
}
}
}
.frame(maxWidth: .infinity)
}
}
.navigationTitle("Products")
.task {
await viewModel.loadNextPage()
}
}
}
}
The example uses the last displayed product to trigger loading. In a production interface, a threshold can be preferable so the next page begins loading before the user reaches the very bottom. Make sure the view model prevents duplicate requests when several row appearance events occur close together.
12. Implementing Pagination in UITableView
UIKit applications commonly use UITableView to display large lists. One approach is to check the visible rows and request more data when the last row appears.
import UIKit
final class ProductTableViewController: UITableViewController {
private let viewModel = ProductListViewModel()
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(
UITableViewCell.self,
forCellReuseIdentifier: "ProductCell"
)
Task {
await viewModel.loadNextPage()
tableView.reloadData()
}
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
viewModel.products.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "ProductCell",
for: indexPath
)
let product = viewModel.products[indexPath.row]
var content = cell.defaultContentConfiguration()
content.text = product.name
content.secondaryText = String(product.price)
cell.contentConfiguration = content
return cell
}
override func tableView(
_ tableView: UITableView,
willDisplay cell: UITableViewCell,
forRowAt indexPath: IndexPath
) {
let threshold = max(viewModel.products.count - 5, 0)
if indexPath.row >= threshold {
Task {
await viewModel.loadNextPage()
tableView.reloadData()
}
}
}
}
This is an instructional example. A production UIKit screen should notify the view controller when the view model changes, update only the required rows where practical, and handle loading and error states with dedicated footer views or other appropriate UI. Avoid reloading the entire table after every page when a more targeted update is appropriate.
13. Pagination in UICollectionView
UICollectionView is useful for product grids, photo galleries, media feeds, and card-based layouts. Pagination can be triggered when a visible item approaches the end of the current dataset.
func collectionView(
_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath
) {
let threshold = max(products.count - 6, 0)
if indexPath.item >= threshold {
Task {
await viewModel.loadNextPage()
}
}
}
The view controller must use a data source backed by the same collection of products. After loading a page, update the collection view using suitable batch updates or a diffable data source snapshot. Apple's collection view APIs also support data prefetching, which can help prepare data before cells appear.
14. Handling Loading Indicators
Users need clear feedback while the app retrieves additional records. A loading indicator may appear in a table footer, collection footer, list row, or dedicated loading cell.
- Show an initial loading indicator when the first page is being fetched.
- Show a smaller loading indicator when retrieving subsequent pages.
- Hide the indicator when the request finishes, whether successful or unsuccessful.
- Avoid showing repeated indicators when multiple scroll callbacks occur.
- Provide a retry action when the request fails.
Separate initial loading from next-page loading if the interface needs different presentations for these states.
15. Handling Errors and Retry Operations
Pagination requests can fail because of connectivity problems, authentication errors, server failures, invalid responses, or decoding errors.
A robust implementation should:
- Display a clear message instead of silently failing.
- Preserve previously loaded records when a later page fails.
- Allow the user to retry the failed request.
- Keep the current cursor unchanged until the page is successfully processed.
- Distinguish temporary network failures from permanent errors such as authorization failures.
- Prevent retry taps from starting multiple concurrent requests.
do {
let page = try await api.fetchProducts(cursor: nextCursor)
products.append(contentsOf: page.items)
nextCursor = page.nextCursor
hasMore = page.hasMore
} catch {
errorMessage = "Could not load more results. Try again."
}
The snippet illustrates the basic error-handling structure. In production, include duplicate prevention and validation before appending data, as shown in the earlier view model example.
16. Preventing Duplicate Requests and Records
Infinite scrolling can generate multiple callbacks while a user scrolls quickly. Without request protection, the app may request the same page more than once.
Use the following safeguards:
- Track whether a page request is currently in progress.
- Check whether additional data is available before starting a request.
- Use a single, well-defined pagination state owner.
- Deduplicate records using stable identifiers.
- Do not advance a page number or cursor before a successful response.
- Ignore responses that belong to an obsolete search or filter request.
For more complex applications, associate each request with a query generation or cancellation token. When the user changes filters or search terms, cancel or invalidate previous requests so that stale responses do not contaminate the new results.
17. Handling Empty Results and End of Data
An empty response does not always mean the dataset has ended. It may represent an empty search result, an invalid cursor, a server-side filtering issue, or a temporary inconsistency.
- Use the API's documented
hasMore, nextCursor, or total-count metadata.
- Display a helpful empty state when the initial request returns no records.
- Stop requesting when the API confirms there are no more results.
- Avoid automatically retrying an unchanged cursor indefinitely.
- Provide a way to adjust the search or filter if no results match.
If an API uses a next-page URL or continuation token, follow its documented rules rather than assuming that a short page always indicates the end.
18. Refreshing Paginated Data
Pull-to-refresh is commonly used to reload the latest content. Refreshing should reset the pagination state and fetch the first page again.
A typical refresh process is:
- Cancel or invalidate older page requests where possible.
- Clear or replace the current records according to the desired refresh behavior.
- Reset the cursor or page number to the initial value.
- Reset the end-of-data flag.
- Fetch the first page.
- Update the interface and display any relevant error state.
For a better user experience, some apps keep existing content visible while refreshing and replace it only after the first-page request succeeds. The appropriate strategy depends on the product requirements.
19. Prefetching Data for Better Performance
Prefetching means starting to load data before the user actually needs to display it. It can reduce visible waiting time, particularly when cells depend on network requests or expensive processing.
In UIKit, UICollectionViewDataSourcePrefetching and the related table-view prefetching APIs can help prepare upcoming content. The app should also be able to configure a cell when prefetched data is available, still loading, or has not been requested.
collectionView.prefetchDataSource = self
extension ProductViewController: UICollectionViewDataSourcePrefetching {
func collectionView(
_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]
) {
for indexPath in indexPaths {
// Start an asynchronous request for the required item
// or its associated image, if it is not already cached.
}
}
func collectionView(
_ collectionView: UICollectionView,
cancelPrefetchingForItemsAt indexPaths: [IndexPath]
) {
// Cancel work that is no longer needed when possible.
}
}
Prefetching should not be treated as a replacement for pagination. Pagination determines which additional records to request; prefetching helps prepare upcoming data. Apple notes that prefetch callbacks are not guaranteed for every item, so normal cell configuration must still handle missing or unfinished prefetched data.
20. Efficient Image Loading and Caching
Large feeds often include thumbnails or other images. Downloading all images at once can increase memory use and network traffic.
- Load images asynchronously.
- Cache downloaded images using a suitable memory and/or disk caching strategy.
- Use appropriately sized thumbnails rather than downloading full-resolution images for every list cell.
- Cancel image requests when cells no longer need the results, where practical.
- Verify that an image response belongs to the correct item before updating a reused cell.
- Use placeholders while images load and provide a fallback for failures.
- Limit simultaneous downloads to avoid overwhelming the device or server.
For custom caching implementations, account for memory pressure and avoid retaining every decoded image indefinitely. Reuse cells and configure them based on the current model rather than assuming a cell remains associated with the same record.
21. Managing Large Local Datasets
Not every large dataset comes from a remote API. Some applications store thousands of records locally using Core Data, SwiftData, SQLite, or files.
For local datasets:
- Use database predicates to filter records before displaying them.
- Sort results consistently to keep pagination predictable.
- Fetch records in batches instead of loading the entire database into an array.
- Use database-supported limits and offsets when appropriate.
- Consider cursor/keyset-style queries for very large tables when supported.
- Perform expensive transformations away from the main thread.
- Use persistent storage for records that must survive app termination.
For example, a local database query might retrieve a maximum of 50 records for a particular screen. The precise implementation depends on the storage framework and the way its fetch APIs handle batching.
22. Searching and Filtering Paginated Data
Search terms, sort order, category selections, and filters affect the set of records returned by the server. When one of these changes, existing pagination state may no longer be valid.
Best practices include:
- Reset the page number or cursor when the query changes.
- Clear old results or mark them as stale while new results load.
- Debounce rapidly changing search input to avoid excessive requests.
- Cancel or ignore responses for previous search terms.
- Ensure the backend applies filtering and sorting consistently across pages.
- Keep query, filters, sort order, and cursor associated with the same request state.
For example, a user may search for “laptops” and then quickly search for “tablets.” A late response from the laptop query should not be appended to the tablet results.
23. Performance Optimization Best Practices
| Practice |
Benefit |
| Load records in reasonable batches |
Reduces initial transfer and memory overhead. |
| Use asynchronous networking |
Keeps network waits off the main thread. |
| Update only changed rows or items |
Avoids unnecessary interface work. |
| Cache images and reusable responses |
Reduces repeated downloads. |
| Deduplicate records by stable ID |
Prevents repeated items in the list. |
| Cancel obsolete requests |
Reduces wasted work and stale results. |
| Use prefetching selectively |
Can reduce waiting during scrolling. |
| Profile with Instruments |
Helps identify memory, CPU, and scrolling bottlenecks. |
24. Common Pagination Problems and Solutions
| Problem |
Possible Cause |
Solution |
| Same records appear repeatedly |
Repeated requests or unstable page boundaries |
Use stable identifiers, request guards, and suitable server ordering. |
| Some records are missing |
Records changed between offset requests |
Use cursor pagination when appropriate and ensure consistent ordering. |
| Loading never stops |
Incorrect end-of-data state |
Follow the API's documented continuation metadata. |
| Scrolling feels slow |
Heavy work on the main thread |
Move expensive processing off the main thread and profile the interface. |
| Several requests run at once |
Repeated scroll callbacks |
Guard requests with loading state and centralize pagination logic. |
| Old search results appear |
Stale requests complete late |
Cancel or invalidate requests when the query changes. |
| Images appear in the wrong cells |
Cell reuse and asynchronous image races |
Validate item identity before applying an image result. |
| Refresh produces duplicate content |
Pagination state was not reset consistently |
Reset cursor, request state, and deduplication state as needed. |
25. Security and Reliability Considerations
- Use HTTPS for network requests.
- Do not trust client-supplied page numbers, cursors, or filters as authorization controls.
- Validate API responses before using their data.
- Handle authentication expiration and authorization failures appropriately.
- Avoid logging access tokens or sensitive response data.
- Apply request timeouts and cancellation policies where suitable.
- Ensure server-side access controls are enforced for every page request.
26. Practical Project: Paginated Product Catalog
Project objective: Build an iOS product catalog that loads records gradually and remains responsive as the user scrolls.
Requirements
- Display product names, prices, and thumbnails in a list or grid.
- Load an initial batch from a paginated API.
- Automatically request more records near the end of the current batch.
- Show a loading indicator during requests.
- Display an error message with a retry action when loading fails.
- Prevent duplicate requests and duplicate product records.
- Support pull-to-refresh or a refresh button.
- Support search and category filters.
- Cache images to reduce unnecessary downloads.
- Stop requesting data when the server indicates that no more results exist.
Suggested Development Steps
- Create a
Product model matching the API response.
- Create an API service that fetches one page at a time.
- Build a view model to own loaded records, pagination state, and errors.
- Create a SwiftUI List or UIKit table/collection view.
- Trigger the next-page request when the user approaches the end.
- Add loading, empty, failure, and retry states.
- Test duplicate callbacks, failed requests, refresh, and search changes.
- Measure performance with a realistically large dataset.
27. Testing Checklist
- Confirm the first page loads correctly.
- Confirm that scrolling requests additional pages.
- Verify that the last page stops further requests.
- Test an empty response and an empty search result.
- Test network disconnection and server errors.
- Retry a failed request and verify that no records are skipped.
- Scroll quickly and verify that requests are not duplicated.
- Refresh while data is loading and check for stale responses.
- Change search terms while a previous request is in progress.
- Check memory use and scrolling performance with many records and images.
28. Interview Questions and Answers
Q1. What is pagination in iOS?
Pagination is a data-loading strategy that retrieves a large dataset in smaller batches, reducing initial loading time and unnecessary memory and network usage.
Q2. What is the difference between page-based and cursor-based pagination?
Page-based pagination identifies a batch using a page number or offset. Cursor-based pagination uses a continuation token supplied by the server and is often suitable for feeds that change frequently.
Q3. What is infinite scrolling?
Infinite scrolling automatically requests additional records as the user approaches the end of the currently displayed content.
Q4. How do you prevent duplicate pagination requests?
Track the loading state, check whether more data exists, centralize request logic, and deduplicate records using stable identifiers.
Q5. How should a failed page request be handled?
Preserve existing records, display a suitable error, allow the user to retry, and keep the current continuation state until the request succeeds.
Q6. Why is cursor pagination useful?
It can provide more stable continuation through large or frequently changing datasets when the server uses a consistent ordering strategy.
Q7. What is data prefetching?
Data prefetching starts preparing data before the corresponding cells need to display it. It can reduce visible delays, but the app must still handle data that has not been prefetched.
Q8. How can pagination improve performance?
It limits the amount of data requested at once, shortens initial loading, and allows the app to focus on content that is currently relevant to the user.
Q9. What should happen when a user changes a search query?
The app should reset pagination state for the new query and cancel or ignore obsolete requests so that results from different searches are not mixed.
Q10. Why should records have stable identifiers?
Stable identifiers support deduplication, reliable updates, and consistent cell rendering when pages overlap or the dataset changes.
29. Quick Revision
- Pagination retrieves data in smaller batches.
- Page-based pagination uses page numbers; offset pagination uses skip counts.
- Cursor-based pagination relies on a server-provided continuation token.
- Infinite scrolling loads more data as the user approaches the end.
- Use URLSession and asynchronous programming for network requests.
- Keep pagination state in a single view model or data-loading component.
- Guard against overlapping requests and duplicate records.
- Handle errors, retries, empty states, refresh, and changed search terms.
- Use caching and prefetching where they measurably improve responsiveness.
- Profile the application with realistic data before release.
30. Conclusion
Implementing pagination and large dataset handling is essential for building scalable, responsive iOS applications. By retrieving records in manageable batches, maintaining reliable pagination state, handling errors, and optimizing image and cell loading, developers can deliver a smooth browsing experience even when working with large datasets.
Choose the pagination strategy that matches the backend API, use stable ordering and identifiers, and test edge cases such as failed requests, refreshes, and rapidly changing searches. These techniques can be applied to shopping catalogs, social feeds, news applications, dashboards, and many other data-driven iOS projects.
Learn more: Explore iOS App Development Training | Register for a Course Demo | Visit JustAcademy